---
title: Business rules
summary: Consolidated catalog of the 21 business rules enforced on PdfAPI's request path.
status: draft
owner: Not determined from source
last_reviewed: 2026-09-17
library: PdfAPI
reference_type: embedded
---

# Business rules

Every rule below was traced to an enforcement point in source. Rules marked *(delegated)* are implemented in a referenced library (`PdfAPI.Core` or `PdfApi.SyncFusionCore`) but are enforced on this API's request path, so they are catalogued here with their true location.

## Rule catalog

| ID | Rule | Origin | Reached from |
| --- | --- | --- | --- |
| [BR-PDFAPI-001](#br-pdfapi-001-null-merge-request-bypasses-the-error-envelope) | Null merge request bypasses the error envelope | api | `POST v1/merge` |
| [BR-PDFAPI-002](#br-pdfapi-002-default-merge-options-substitution) | Default merge options substitution | api | `POST v1/merge` |
| [BR-PDFAPI-003](#br-pdfapi-003-processor-selection-by-configuration) | Processor selection by configuration | api | Every controller construction |
| [BR-PDFAPI-004](#br-pdfapi-004-extraction-requires-pdf-validation) | Extraction requires PDF validation | api | `POST v1/extract` |
| [BR-PDFAPI-005](#br-pdfapi-005-pdf-validity-by-file-signature) | PDF validity by file signature | library (delegated) | Multiple endpoints |
| [BR-PDFAPI-006](#br-pdfapi-006-merge-aborts-entirely-on-pre-conversion-failure) | Merge aborts entirely on pre-conversion failure | api | `POST v1/merge` |
| [BR-PDFAPI-007](#br-pdfapi-007-pdf-a-output-silently-downgraded) | PDF-A output silently downgraded | api | `POST v1/merge` |
| [BR-PDFAPI-008](#br-pdfapi-008-unsupported-extension-leaves-status-at-default) | Unsupported extension leaves status at default | library (delegated) | `POST v1/convert`, `POST v1/convert/{fileExtension}` |
| [BR-PDFAPI-009](#br-pdfapi-009-plain-text-double-conversion) | Plain text double conversion | library (delegated) | `POST v1/convert`, `POST v1/convert/{fileExtension}` |
| [BR-PDFAPI-010](#br-pdfapi-010-tracking-forced-on-with-heat-email) | Tracking forced on with heat email | library (delegated) | Convert/merge/extract |
| [BR-PDFAPI-011](#br-pdfapi-011-tracking-status-lifecycle) | Tracking status lifecycle | api | All tracked endpoints |
| [BR-PDFAPI-012](#br-pdfapi-012-tracking-degrades-gracefully-without-a-unit-of-work) | Tracking degrades gracefully without a unit of work | api | All tracking operations |
| [BR-PDFAPI-013](#br-pdfapi-013-download-requires-succeeded-status-and-output-path) | Download requires succeeded status and output path | api | `GET v1/legacy/download/{trackingId}` |
| [BR-PDFAPI-014](#br-pdfapi-014-status-check-polling-loop) | Status check polling loop | api | `GET v1/info/track/{trackingId}` |
| [BR-PDFAPI-015](#br-pdfapi-015-incident-email-on-failure) | Incident email on failure | api | Convert/merge/extract on failure |
| [BR-PDFAPI-016](#br-pdfapi-016-save-input-to-temp-storage) | Save input to temp storage | api | Convert/merge/extract |
| [BR-PDFAPI-017](#br-pdfapi-017-processing-failures-return-200-with-a-pdfresult) | Processing failures return 200 with a PdfResult | api | Convert/extract/merge/download |
| [BR-PDFAPI-018](#br-pdfapi-018-page-range-clamping-and-reversal) | Page range clamping and reversal | library (delegated) | `POST v1/extract` |
| [BR-PDFAPI-019](#br-pdfapi-019-skip-conversion-when-input-is-already-pdf) | Skip conversion when input is already PDF | api | `POST v1/convert`, `POST v1/convert/{fileExtension}` |
| [BR-PDFAPI-020](#br-pdfapi-020-activepdf-retry-behavior) | ActivePDF retry behavior | api | Convert/merge (ActivePDF processor only) |
| [BR-PDFAPI-021](#br-pdfapi-021-form-field-inspection-client-selection) | Form field inspection client selection | api | `POST v1/info/fields/exist`, `POST v1/info/fields/flatten` |

---

## BR-PDFAPI-001 Null merge request bypasses the error envelope

| Field | Value |
| --- | --- |
| **Statement** | A merge request body must not be null; the guard runs before the action's error envelope, so a null body fails as a framework error rather than a `PdfResult` |
| **Trigger** | `POST v1/merge` invoked with a null request body |
| **Enforcement** | `Enforce.ArgumentNotNull(request, "request")`, called **outside** the surrounding `try` block |
| **Origin** | api |
| **Reached from** | [`POST v1/merge`](endpoints/merge-controller.md#post-v1%2Fmerge) |
| **Outcome** | Framework-level error response, not the per-action `PdfResult` envelope used by every other failure on this action |
| **Source** | `PdfAPI\Controllers\v1\MergeController.cs` |

## BR-PDFAPI-002 Default merge options substitution

| Field | Value |
| --- | --- |
| **Statement** | When a merge request omits `Options`, a default `PdfOptions` is substituted (output format `Pdf`, not PDF/A) |
| **Trigger** | `POST v1/merge` invoked with `Options == null` |
| **Enforcement** | `request.Options ??= new PdfOptions()` |
| **Origin** | api |
| **Reached from** | [`POST v1/merge`](endpoints/merge-controller.md#post-v1%2Fmerge) |
| **Outcome** | Merge proceeds with default (non-PDF/A) options rather than failing |
| **Source** | `PdfAPI\Controllers\v1\MergeController.cs` |

## BR-PDFAPI-003 Processor selection by configuration

| Field | Value |
| --- | --- |
| **Statement** | The PDF processor is selected per request from configuration: `pdfapi.pdfservicetype == "activepdf"` selects ActivePDF; any other value, including missing, selects Syncfusion |
| **Trigger** | Every controller constructor call to `pdfServiceFactory.Create()` |
| **Enforcement** | `PdfServiceFactory.Create()` lower-cases the configured value and switches on it; no validation, no failure path for an unrecognized value |
| **Origin** | api |
| **Reached from** | Every endpoint (constructor-time) |
| **Outcome** | Silent fallback to Syncfusion for any value other than the literal `"activepdf"` |
| **Source** | `PdfAPI\Services\PdfServiceFactory.cs:43` |

## BR-PDFAPI-004 Extraction requires PDF validation

| Field | Value |
| --- | --- |
| **Statement** | Page extraction validates that the source document is a real PDF before any extraction work |
| **Trigger** | `POST v1/extract` |
| **Enforcement** | `PdfClientService.ValidatePdf` called before `PdfExtractor.Extract` |
| **Origin** | api |
| **Reached from** | [`POST v1/extract`](endpoints/extract-controller.md#post-v1%2Fextract) |
| **Outcome** | Non-PDF input raises `PdfException` before any extraction occurs |
| **Source** | `PdfAPI\Services\Syncfusion\SyncfusionPdfService.cs` |

## BR-PDFAPI-005 PDF validity by file signature

| Field | Value |
| --- | --- |
| **Statement** | PDF validity is determined by the `%PDF-` file signature; failure raises a `PdfException` reporting that the file is not a valid PDF document (exact message text is not visible in this repository — delegated to `PdfAPI.Core\Services\PdfClientService.cs`) |
| **Trigger** | Any operation that calls `PdfClientService.ValidatePdf` or `IsPdf` |
| **Enforcement** | Byte-signature check in `PdfClientService` |
| **Origin** | library (delegated — `PdfAPI.Core\Services\PdfClientService.cs`) |
| **Reached from** | `POST v1/extract`, `POST v1/info/pages/count`, `POST v1/info/validate`, `POST v1/info/version` |
| **Outcome** | `PdfException` on a non-PDF payload wherever this validation path is used; `POST v1/info/validate` alone returns `false` instead of throwing ([BR-PDFAPI-005](#br-pdfapi-005-pdf-validity-by-file-signature) via `IsPdf`) |
| **Source** | `PdfAPI.Core\Services\PdfClientService.cs` |

## BR-PDFAPI-006 Merge aborts entirely on pre-conversion failure

| Field | Value |
| --- | --- |
| **Statement** | In a merge, every non-PDF input is converted to PDF first; if any pre-conversion fails, a `PdfException` is thrown and the whole merge is abandoned — no partial output is produced |
| **Trigger** | `POST v1/merge` with one or more non-PDF input documents |
| **Enforcement** | Pre-conversion loop in `SyncfusionPdfService.Merge` |
| **Origin** | api |
| **Reached from** | [`POST v1/merge`](endpoints/merge-controller.md#post-v1%2Fmerge) |
| **Outcome** | A single failing input document fails the entire merge request; no documents are merged |
| **Source** | `PdfAPI\Services\Syncfusion\SyncfusionPdfService.cs` |

## BR-PDFAPI-007 PDF-A output silently downgraded

| Field | Value |
| --- | --- |
| **Statement** | When PDF/A output is requested but the produced document fails PDF/A validation, the result is silently downgraded to `PdfFormat.Pdf` rather than failing the request |
| **Trigger** | `POST v1/merge` with `Options.IsPdfa == true` and the merged output fails `ValidatePdfA` |
| **Enforcement** | Conditional downgrade in `SyncfusionPdfService.Merge` |
| **Origin** | api |
| **Reached from** | [`POST v1/merge`](endpoints/merge-controller.md#post-v1%2Fmerge) |
| **Outcome** | Caller receives a plain PDF with `200 OK` and no explicit failure signal that PDF/A was not honored |
| **Source** | `PdfAPI\Services\Syncfusion\SyncfusionPdfService.cs` |

## BR-PDFAPI-008 Unsupported extension leaves status at default

| Field | Value |
| --- | --- |
| **Statement** | An unrecognized source file extension yields a failure result with message `"Unsuppported extension ({ext})"` (spelling as in source). The result's `Status` is left at the enum default `NotTracked`, not `Failed` — a caller checking `Status == Failed` will not detect this case |
| **Trigger** | `POST v1/convert` / `POST v1/convert/{fileExtension}` with an unrecognized extension |
| **Enforcement** | Extension dispatch switch in `ConverterFactory.Convert` |
| **Origin** | library (delegated — `PdfApi.SyncFusionCore\Convert\ConverterFactory.cs`) |
| **Reached from** | [`POST v1/convert`](endpoints/convert-controller.md#post-v1%2Fconvert), [`POST v1/convert/{fileExtension}`](endpoints/convert-controller.md#post-v1%2Fconvert%2F%7Bfileextension%7D) |
| **Outcome** | `PdfResult` with a failure message but `Status == NotTracked`, not `Failed` — a caller inspecting only `Status == Failed` misses this case |
| **Source** | `PdfApi.SyncFusionCore\Convert\ConverterFactory.cs` |

## BR-PDFAPI-009 Plain text double conversion

| Field | Value |
| --- | --- |
| **Statement** | Plain-text input is converted twice: `txt` → intermediate document → PDF, re-entering the PDF conversion path |
| **Trigger** | `POST v1/convert` / `POST v1/convert/{fileExtension}` with a `.txt` source |
| **Enforcement** | Two-stage dispatch in `ConverterFactory.Convert` |
| **Origin** | library (delegated — `PdfApi.SyncFusionCore\Convert\ConverterFactory.cs`) |
| **Reached from** | [`POST v1/convert`](endpoints/convert-controller.md#post-v1%2Fconvert), [`POST v1/convert/{fileExtension}`](endpoints/convert-controller.md#post-v1%2Fconvert%2F%7Bfileextension%7D) |
| **Outcome** | Text input incurs two conversion passes rather than one direct conversion |
| **Source** | `PdfApi.SyncFusionCore\Convert\ConverterFactory.cs` |

## BR-PDFAPI-010 Tracking forced on with heat email

| Field | Value |
| --- | --- |
| **Statement** | Tracking is enabled by default and is forced on whenever `SendHeatEmailOnFailure` is set, regardless of the caller's `TrackingEnabled` value |
| **Trigger** | Any convert/merge/extract request with `SendHeatEmailOnFailure = true` |
| **Enforcement** | `TrackingEnabled` getter override in `PdfRequest` |
| **Origin** | library (delegated — `PdfAPI.Core\Request\PdfRequest.cs`) |
| **Reached from** | `POST v1/convert`, `POST v1/convert/{fileExtension}`, `POST v1/merge`, `POST v1/extract` |
| **Outcome** | A caller cannot disable tracking while also requesting incident email notification |
| **Source** | `PdfAPI.Core\Request\PdfRequest.cs` |

## BR-PDFAPI-011 Tracking status lifecycle

| Field | Value |
| --- | --- |
| **Statement** | A tracked request moves `SCHED` → `PROC` → terminal (`SUCC` / `ERROR` / `CANCE` / `ABORT`); these persisted codes map onto the `PdfStatus` enum |
| **Trigger** | Any tracked convert/merge/extract request |
| **Enforcement** | Status transitions in `PdfTrackingService` |
| **Origin** | api |
| **Reached from** | All tracked endpoints |
| **Outcome** | Consistent status vocabulary between the persisted tracking row and `PdfStatus` returned to callers |
| **Source** | `PdfAPI\Services\PdfTrackingService.cs` |

## BR-PDFAPI-012 Tracking degrades gracefully without a unit of work

| Field | Value |
| --- | --- |
| **Statement** | When tracking is unavailable (no unit of work), every tracking operation degrades gracefully and returns a non-tracked result instead of failing the request |
| **Trigger** | `_uow == null` at the point a tracking operation is invoked |
| **Enforcement** | `if (_uow == null)` guard on every method in `PdfTrackingService` |
| **Origin** | api |
| **Reached from** | All tracking operations |
| **Outcome** | Requests continue to succeed functionally even when tracking persistence is unavailable |
| **Source** | `PdfAPI\Services\PdfTrackingService.cs` |

## BR-PDFAPI-013 Download requires succeeded status and output path

| Field | Value |
| --- | --- |
| **Statement** | A tracked document is downloadable only when its status is `Succeeded` and a non-null output file path is recorded; otherwise an explanatory failure result is returned with HTTP `200` |
| **Trigger** | `GET v1/legacy/download/{trackingId}` |
| **Enforcement** | Status/path check in `PdfTrackingService.DownloadByTrackingId` |
| **Origin** | api |
| **Reached from** | [`GET v1/legacy/download/{trackingId}`](endpoints/legacy-controller.md#get-v1%2Flegacy%2Fdownload%2F%7Btrackingid%7D) |
| **Outcome** | Callers must inspect `PdfResult.Status`/`Message`; HTTP status alone never signals a missing or not-yet-ready document |
| **Source** | `PdfAPI\Services\PdfTrackingService.cs` |

## BR-PDFAPI-014 Status check polling loop

| Field | Value |
| --- | --- |
| **Statement** | A status check polls while the status is `Scheduled` or `Processing`, sleeping `retryIntervalMilliseconds` between reads, up to `retryCount` attempts; the default `retryCount = 0` makes it a single read |
| **Trigger** | `GET v1/info/track/{trackingId}` |
| **Enforcement** | Polling loop in `PdfTrackingService.CheckPdfTrackingStatus` |
| **Origin** | api |
| **Reached from** | [`GET v1/info/track/{trackingId}`](endpoints/info-controller.md#get-v1%2Finfo%2Ftrack%2F%7Btrackingid%7D) |
| **Outcome** | Caller-controlled synchronous wait for a still-processing tracked request |
| **Source** | `PdfAPI\Services\PdfTrackingService.cs` |

## BR-PDFAPI-015 Incident email on failure

| Field | Value |
| --- | --- |
| **Statement** | A failed request sends an incident email only when the caller set `SendHeatEmailOnFailure`; the subject is taken from the tracking record's `ApplicationError.HeatEmailSubject` |
| **Trigger** | Convert/merge/extract failure with `SendHeatEmailOnFailure = true` |
| **Enforcement** | `PdfService.SendIncidentEmail`, invoked from `FinalizeResult` |
| **Origin** | api |
| **Reached from** | `POST v1/convert`, `POST v1/convert/{fileExtension}`, `POST v1/merge`, `POST v1/extract` |
| **Outcome** | Conditional outbound SMTP email on failure, addressed and subjected from configuration/tracking data |
| **Source** | `PdfAPI\Services\PdfService.cs` |

## BR-PDFAPI-016 Save input to temp storage

| Field | Value |
| --- | --- |
| **Statement** | When `pdfapi.saveInputToTemp` is enabled, the inbound source document is written to temporary storage before processing |
| **Trigger** | Any convert/merge/extract request when the setting is `true` |
| **Enforcement** | Conditional write in `PdfService` and its use sites |
| **Origin** | api |
| **Reached from** | `POST v1/convert`, `POST v1/convert/{fileExtension}`, `POST v1/merge`, `POST v1/extract` |
| **Outcome** | Extra disk I/O and a temporary copy of every submitted document when enabled |
| **Source** | `PdfAPI\Services\PdfService.cs:30` |

## BR-PDFAPI-017 Processing failures return 200 with a PdfResult

| Field | Value |
| --- | --- |
| **Statement** | For convert, extract, merge, and download, a processing failure is not an HTTP failure: the exception is logged and returned as a `PdfResult` carrying a message, with status code `200`. Callers must inspect the payload |
| **Trigger** | Any handled exception inside the per-action `try`/`catch` on these four controllers |
| **Enforcement** | `try`/`catch` + `ILogService.LogException` + `Ok(new PdfResult(ex.Message, request: request))` |
| **Origin** | api |
| **Reached from** | `POST v1/convert`, `POST v1/convert/{fileExtension}`, `POST v1/extract`, `POST v1/merge`, `GET v1/legacy/download/{trackingId}` |
| **Outcome** | HTTP status code alone never signals failure on these five endpoints |
| **Source** | `PdfAPI\Controllers\v1\ConvertController.cs`, `ExtractController.cs`, `MergeController.cs`, `LegacyController.cs` |

## BR-PDFAPI-018 Page range clamping and reversal

| Field | Value |
| --- | --- |
| **Statement** | Page ranges are clamped to the document's page count, out-of-range ranges are skipped rather than erroring, descending ranges extract pages in reverse order, and a single `IsOriginal` range returns the original file untouched |
| **Trigger** | `POST v1/extract` with one or more page ranges |
| **Enforcement** | Range-handling logic in `PageRange` and `PdfExtractor.Extract` |
| **Origin** | library (delegated — `PdfAPI.Core\Extract\PageRange.cs` + `PdfApi.SyncFusionCore\Extract\PdfExtractor.cs`) |
| **Reached from** | [`POST v1/extract`](endpoints/extract-controller.md#post-v1%2Fextract) |
| **Outcome** | Tolerant range handling — no error for out-of-range or descending page ranges |
| **Source** | `PdfAPI.Core\Extract\PageRange.cs`, `PdfApi.SyncFusionCore\Extract\PdfExtractor.cs` |

## BR-PDFAPI-019 Skip conversion when input is already PDF

| Field | Value |
| --- | --- |
| **Statement** | When `SkipPdf` is set (the default) and the input is already a PDF, conversion is short-circuited and the input is returned as the output |
| **Trigger** | `POST v1/convert` / `POST v1/convert/{fileExtension}` with a PDF source and `SkipPdf` at its default `true` |
| **Enforcement** | Short-circuit check in `ActivePdfService` |
| **Origin** | api |
| **Reached from** | [`POST v1/convert`](endpoints/convert-controller.md#post-v1%2Fconvert), [`POST v1/convert/{fileExtension}`](endpoints/convert-controller.md#post-v1%2Fconvert%2F%7Bfileextension%7D) |
| **Outcome** | No conversion work is performed for an already-PDF source under the ActivePDF processor |
| **Source** | `PdfAPI\Services\ActivePdf\ActivePdfService.cs`, `PdfAPI.Core\Request\ConvertRequest.cs` |

## BR-PDFAPI-020 ActivePDF retry behavior

| Field | Value |
| --- | --- |
| **Statement** | On the ActivePDF processor, a PDF/A production failure is retried as plain PDF, and configured error codes (`pdfApi.ErrorsToRetry`) trigger a retry of the whole operation |
| **Trigger** | ActivePDF processor conversion/merge failure matching a configured retryable error code, or a PDF/A production failure |
| **Enforcement** | Retry logic in `ActivePdfService` |
| **Origin** | api |
| **Reached from** | `POST v1/convert`, `POST v1/convert/{fileExtension}`, `POST v1/merge` (ActivePDF processor only) |
| **Outcome** | Transient ActivePDF failures are retried automatically; PDF/A failures fall back to plain PDF |
| **Source** | `PdfAPI\Services\ActivePdf\ActivePdfService.cs` |

## BR-PDFAPI-021 Form field inspection client selection

| Field | Value |
| --- | --- |
| **Statement** | Form-field inspection and flattening default to the iTextSharp client implementation (`useClient = true`) and use the Syncfusion helper only when the caller explicitly opts out |
| **Trigger** | `POST v1/info/fields/exist` or `POST v1/info/fields/flatten` |
| **Enforcement** | `useClient` parameter branch in `InfoController` and `PdfService` |
| **Origin** | api |
| **Reached from** | [`POST v1/info/fields/exist`](endpoints/info-controller.md#post-v1%2Finfo%2Ffields%2Fexist), [`POST v1/info/fields/flatten`](endpoints/info-controller.md#post-v1%2Finfo%2Ffields%2Fflatten) |
| **Outcome** | Two independent code paths (iTextSharp vs Syncfusion) can produce the inspection/flattening result depending on the `useClient` value |
| **Source** | `PdfAPI\Controllers\v1\InfoController.cs`, `PdfAPI\Services\PdfService.cs` |

## Related documentation
- [API index](index.md)
- [Overview](overview.md)
- [Architecture](architecture.md)
- [Endpoints](endpoints.md)
- [Authentication and authorization](authentication-authorization.md)
- [Dependencies](dependencies.md)
- [Configuration](configuration.md)
- [Operations and processes](operations-processes.md)
- [Data model](data-model.md)
- [Operational runbook](operational-runbook.md)
- [Tech debt](tech-debt.md)

> **Note:** Statements marked with `*` were inferred from code structure, naming conventions, or
> contextual evidence where direct confirmation was not available in source artifacts. Statements
> marked with `**` could not be confirmed; treat as unknown until verified.

## Sources
- PdfAPI\Controllers\v1\ConvertController.cs
- PdfAPI\Controllers\v1\ExtractController.cs
- PdfAPI\Controllers\v1\MergeController.cs
- PdfAPI\Controllers\v1\LegacyController.cs
- PdfAPI\Controllers\v1\InfoController.cs
- PdfAPI\Services\PdfServiceFactory.cs
- PdfAPI\Services\PdfService.cs
- PdfAPI\Services\PdfTrackingService.cs
- PdfAPI\Services\Syncfusion\SyncfusionPdfService.cs
- PdfAPI\Services\ActivePdf\ActivePdfService.cs
- PdfAPI.Core\Request\PdfRequest.cs
- PdfAPI.Core\Request\ConvertRequest.cs
- PdfAPI.Core\Extract\PageRange.cs
- PdfAPI.Core\Services\PdfClientService.cs
- PdfApi.SyncFusionCore\Convert\ConverterFactory.cs
- PdfApi.SyncFusionCore\Extract\PdfExtractor.cs
